Harden hypervisor process liveness checks - #363
Conversation
f9e6580 to
3c8b92f
Compare
3c8b92f to
915f0ef
Compare
915f0ef to
a187984
Compare
76b9f42 to
78fc483
Compare
78fc483 to
f8fbe79
Compare
ea4603c to
f17f33e
Compare
9c1fbaf to
1a1ff46
Compare
7dbe8e7 to
be6c8ff
Compare
be6c8ff to
fdd7b9a
Compare
fdd7b9a to
bba964c
Compare
When legacy metadata carries a live stored PID but no boot-scoped identity, resolveLiveHypervisorPID failed closed on ErrNoOwningProcess, wedging stop and delete forever once the PID was recycled. That error means both the socket-listener scan and the full command-line scan found nothing, and a live hypervisor always holds its control-socket listener - the same signal already treated as dead when the stored PID no longer exists. Return dead instead of erroring so pre-upgrade instances stay deletable after PID reuse. Also document that HypervisorProcessExists fails open by design.
resolveRuntimeHypervisorPID minted a boot-scoped identity token for a fallback PID that ProcessExists had just disproven, stamping the current boot ID (and, if the PID were recycled mid-call, a live start time) onto a process that is not the hypervisor. Record the bare PID with a cleared token instead, matching the command-line-only branch.
shutdownHypervisor SIGKILLed the raw stored PID and removed the control socket without the ownership checks the other kill paths enforce, so a recycled PID could be signaled and the fail-closed stop fallback was undermined. Resolve the live owner first: kill the resolved PID only, skip the kill when the recorded hypervisor is provably gone, and fail closed before any teardown when ownership cannot be confirmed, keeping the socket as evidence for the hardened kill path.
When the control client cannot be built but the resolved socket owner is alive, shutdownHypervisor force kills the owner instead of reporting a completed shutdown and unlinking the socket over a running VMM. A command-line-only match that is already dead now classifies as provable death rather than failing closed: the socket-owner scan found nothing and no live process matches the command line, the same conclusion as ErrNoOwningProcess. The classification moves into classifyResolvedHypervisorOwner so that race is directly testable.
refreshHypervisorPID ran resolveLiveHypervisorPID on every hydration of every running instance, so each list/get paid socket resolution — worst case a full /proc scan — for instances mid-transition. Hydration is read-only and its answer never authorizes teardown: stop, delete, standby, and the vGPU release guards all re-resolve identity before acting. Restore the cheap contract: trust a live stored PID, resolve the socket only when it is dead, and mint the identity token only for a confirmed owner. Durable token backfill for legacy metadata stays with BackfillHypervisorProcessIdentities at startup, which persists it; hydration's in-memory upgrade never did.
Embed HypervisorProcessIdentity in StoredMetadata anonymously so the persisted JSON keys are unchanged, replace the nine hand-cleared (PID, StartTime, BootID) sites with Set/SetUnconfirmed/Clear methods, and pass the struct to resolveLiveHypervisorPID instead of three positional fields.
killProcessAndWait replaces the kill blocks in killHypervisor, forceKillHypervisorProcess, and forceKillHypervisorPID. All callers now get the process-group escalation standby already had, and the SIGKILL wait used by stop and delete is a named constant.
Count backfilled, skipped, and failed instances and emit one info line so a wholesale backfill failure is visible without debug logging.
A hypervisor that survives SIGKILL is stuck in uninterruptible sleep and no amount of waiting unsticks it, so the long wait only slowed down stop and delete. killProcessAndWait still escalates to the process group and grants a 2s grace period after the initial wait.
Delete no longer fails when the hypervisor cannot be confirmed dead or instance data removal fails. Instead the instance is marked pending-delete: it disappears from list, get, and name lookups (freeing its name for reuse), the delete lifecycle event fires, and the API call returns success. A background finalizer retries the remaining teardown until the hypervisor is provably gone, including a pass at startup where the boot-scoped process identity settles the stuck case immediately after a host reboot. Admission capacity stays held until finalization since the stuck hypervisor may still pin its memory and devices, and TAP GC preserves pending-delete TAPs for the same reason. A new hypeman_instances_pending_delete_total gauge exposes wedged teardowns.
This reverts commit 2559a7d.
HypervisorProcessIdentityExists and HypervisorProcessExists have no callers in this change; the vGPU work that consumes them introduces them itself. killProcessAndWait always waited 2s regardless of which of the two identically-valued constants callers passed, so the parameter goes away.
The resolver disproof branches (boot mismatch, start-time mismatch) were each tested through killHypervisor with subprocess scaffolding; test them at the resolver level instead and keep one wrapper-level no-op test. Fold the classify fail-closed/provable-death cases into one table test and drop the kill-level duplicate. Replace the three backfill tests that only exercised the needsHypervisorIdentityBackfill predicate with a direct table test.
Both resolved the live owner and called killProcessAndWait; the only differences were an early return already handled inside resolveLiveHypervisorPID and a trailing socket remove that is a no-op on stop's path (shutdownHypervisor already removed it).
standbyInstance logged and continued when shutdownHypervisor failed, then released the TAP and cleared the process identity — written when that error meant only "graceful API failed after we killed the raw PID". With fail-closed ownership checks the error can now mean nothing was killed, so continuing would release the network under a live paused VMM and erase the only identity that could ever find it again. Standby now resumes the VM and returns the error; the snapshot on disk is harmless and a retried standby redoes it. shutdownHypervisor's trailing graceful-API error is demoted to a warning since it only fires after the process is confirmed gone, giving the function a single contract: error means the hypervisor may still be running.
shutdownHypervisor unlinked the control socket via a defer on every return and again before waiting for the process to exit. On the paths where the kill fails and standby resumes the VM, that left a live hypervisor with no socket file, so a later graceful standby or stop could not connect and fell straight into the force-kill path. Unlink the socket only once the hypervisor is provably gone, matching the contract killHypervisor already follows on delete and stop.
A process matching the socket path by command line only can never authorize teardown, so the fallback's sole effect was distinguishing provable death from ambiguity. The fd scan runs with CAP_SYS_PTRACE (hypeman runs as root or with full caps), so it cannot miss a live owner and a missing listener already proves the hypervisor is gone. The fallback was also actively harmful: a debug client holding the socket path in its argv (ch-remote, socat) resolved as an unconfirmed live match and wedged stop/delete until it exited. Resolution now trusts the listener scan alone: a confirmed owner is returned, no owner classifies as provable death, and only a failed scan fails closed.
shutdownHypervisor returns nil only when the hypervisor is confirmed gone, so the unconditional killHypervisor that followed was a redundant re-resolution. Run it only when shutdown fails. Also make pidBySocketRef prefer the expected owner when the full scan finds the listener fd in multiple processes: the scan's observation is the same evidence the fast path uses, so a child transiently sharing the inherited fd must not turn a proven owner into a fail-closed error when the fast path misses on a transient fd-dir read failure.
The backfill existed to persist identity tokens for legacy metadata so destructive paths could skip socket resolution. Correctness never depended on it: a token-less live PID is confirmed through the expected-owner fast path (one /proc/net/unix read plus one fd-dir read), and after a reboot the empty listener scan classifies the recorded hypervisor as provably dead. Stop, delete, and standby re-resolve ownership on every call regardless, and instances mint tokens on create/restore, so the startup pass and its metadata rewrites bought nothing.
resolveLiveHypervisorPID treats a zombie stored PID as dead, so the confirmed-gone paths in stop, delete, and standby never reach the Wait4 in WaitForProcessExit and leaked one zombie per direct-child VMM that exited on its own. Reap it non-blockingly where the dead PID is observed. TestResolveProcessPIDDuringProcessChurn passed the expected owner as a hint, so the fast path answered from the test process's own fd table and the full /proc scan the test exists to exercise never ran. Resolve without the hint.
e824690 to
90edafb
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 90edafb. Configure here.
| _ = syscall.Kill(-pid, syscall.SIGKILL) | ||
| if !WaitForProcessExit(pid, hypervisorSIGKILLWaitTimeout) { | ||
| return fmt.Errorf("hypervisor pid %d did not exit after SIGKILL", pid) | ||
| } |
There was a problem hiding this comment.
SIGKILL wait too short for teardown
High Severity
killProcessAndWait only waits about four seconds after SIGKILL (two two-second polls), then reports the hypervisor still alive. Stop and delete now fail closed on that error and keep metadata, so a VMM in ordinary uninterruptible teardown of guest memory or VFIO state is treated as stuck. That yields API 500s and a blocked restart policy until a later retry happens to see the process gone, instead of waiting long enough for a normal kill to finish.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 90edafb. Configure here.
Delete fails closed when the VMM has not exited within its short post-SIGKILL wait, by design: it stays fast and returns a retryable error rather than tearing down resources under a possibly-live process. On loaded CI hosts kernel-side teardown routinely outlasts that wait, so the cleanup deletes in the standby/restore, network, and version-upgrade tests flaked on the first attempt. Retry them like the platform does; the delete converges once the process finishes dying.


Summary
Layer 1 of the vendor VFIO vGPU stack (
generalize-vgpu-device← this ←vendor-vfio-backend←vendor-vfio-vgpu). Pure hypervisor-process hardening with no vGPU-specific code; reviewable in isolation.The upper layers guard vGPU release decisions on "is this instance's hypervisor still alive", so the liveness answer has to be trustworthy first:
ProcessExists— one exported, EPERM-aware, zombie-filtering definition instead of scattered barekill(pid, 0)probes. EPERM means the process exists but cannot be signaled; treating it as dead would be wrong.Wait4returns ECHILD immediately and the kill loop finished before the process had exited. Poll for actual exit in that case.ch-remote,socat) holding the path in its argv blocked stop/delete with a 500 until it exited. The fd scan runs with root/CAP_SYS_PTRACE(documented onResolveProcessPID), so a missing listener is proof of death on its own. If hypeman is ever de-privileged below that, this needs revisiting.Failure contract
When the hypervisor can be confirmed dead or killed, delete completes synchronously (worst case ~2s graceful + SIGKILL waits). When ownership is genuinely unprovable — the /proc socket scan itself fails, or a process survives SIGKILL — stop/delete return an error (API 500) with metadata retained and the restart policy blocked, so a retried delete is safe and converges once the ambiguity clears. An earlier revision handled this with a background delete finalizer; it was removed in favor of the synchronous error after review — the ambiguous cases are rare, self-resolving, and better surfaced loudly than retried silently.
Testing
go build ./...,go vetclean/proc: all pass. Validated against the host's production firecracker binary: a live VMM resolves with no hint, the correct owner hint, and a wrong hint; a SIGKILLed VMM whose socket file remains on disk classifies as provably dead; a dead socket path held in a live process's argv resolves as provably dead instead of wedginggo test -race ./lib/instances/targeted suites pass (TestCreateInstanceWithNetworkrequires image pulls + iptables and fails in this environment on the unmodified base as well;TestStandbyAndRestore/TestForkCloudHypervisorFromRunningNetworkboot real VMs and cannot run in this sandbox — CI covers them)Note
High Risk
Changes how stop/delete/standby identify and SIGKILL hypervisor processes. Incorrect ownership checks could kill the wrong PID or leave a live VMM while tearing down devices.
Overview
Stop, delete, and standby no longer treat a stored hypervisor PID as the VMM. They resolve a boot-scoped process identity (PID +
/procstart time + host boot ID) or confirmed Unix-socket ownership before signaling, and they fail closed if ownership cannot be proven.A recycled PID, a debug client that only has the socket path in argv, or a previous-boot identity is treated as dead and is never killed. Delete/standby abort (metadata kept) if SIGKILL does not confirm exit, so later retries can converge. Socket resolution now matches only the listening inode (
SO_ACCEPTCON), prefers an expected owner when a child inherits the fd, and drops the cmdline fallback.On-disk metadata stays compatible:
HypervisorProcessIdentityis embedded so JSON keys remain flat.Reviewed by Cursor Bugbot for commit 6847d62. Bugbot is set up for automated code reviews on this repo. Configure here.